/*
 * sapphire-backend
 *
 * Copyright (C) 2018 Alyssa Rosenzweig
 * Copyright (C) 2018 libpurple authors
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02111-1301  USA
 *
 */

#include <stdint.h>

#include "purple.h"

#include <assert.h>
#include <glib.h>

#include <signal.h>
#include <string.h>
#ifndef _WIN32
#include <unistd.h>
#else
#include "win32/win32dep.h"
#endif

#include "core.h"
#include "websocket.h"
#include "push.h"
#include "event-loop.h"
#include "json_compat.h"

#define PLUGIN_SAVE_PREF       "/purple/sapphire/plugins/saved"
#define SAPPHIRE_PASSWORD_PREF "/purple/sapphire/password"
#define UI_ID                  "sapphire"

#define purple_serv_send_typing serv_send_typing
#define purple_serv_join_chat serv_join_chat

/* List of connected accounts */
GSList *purple_accounts;

/* List of SapphireChats, whether they are conversations yet or not */
GSList *chats;

/* Hash table of channel IDs to lists of unacked messages ready for replay */
GHashTable *id_to_unacked_list;

/* Hash table of chat IDs to PurpleChats */
GHashTable *id_to_chat;

/* Account ID to PurpleAccount */
GHashTable *id_to_account;

/* Blist Chat ID set */
GHashTable *id_to_joined;

/* All known buddies/other users are maintained in a hash table from network
 * serializable identifier to PurpleBuddy, since we can't transmit the buddy
 * object itself each time, this enables pass-by-reference */

GHashTable *id_to_buddy;

GHashTable *blist_id_to_conversation;

/* Our internal tracking for chats, whether they are joined as
 * PurpleConversations or not. Smoothes over PurpleChat, PurpleConversation,
 * and room lists */

typedef struct {
	const char *id; /* Unique, prpl-agnostic ID */
	const char *account_id; /* Account ID corresponding to account */
	const char *name; /* User visible name */
	const char *group; /* Group name, like from the blist */

	PurpleAccount *account;

	/* Bits needed to join, if roomlist */
	PurpleRoomlist *roomlist;
	PurpleRoomlistRoom *room;

	/* Corresponding conversation, if we have joined */
	PurpleConversation *conv;
} SapphireChat;

static gchar *
sapphire_serialize_account_id(PurpleAccount *account);

/* Creates a new heap-allocated SapphireChat. Must be freed later. */

static SapphireChat *
sapphire_new_chat(PurpleAccount *account, const char *id, const char *name, const char *group)
{
	SapphireChat *schat = g_new0(SapphireChat, 1);

	schat->id = id;
	schat->account = account;
	schat->account_id = sapphire_serialize_account_id(account);
	schat->name = g_strdup(name);
	schat->group = g_strdup(group);

	return schat;
}

static gchar *
sapphire_id_from_conv(PurpleConversation *chat);

static SapphireChat *
sapphire_chat_from_conv(PurpleConversation *conv)
{
	return sapphire_new_chat(
		purple_conversation_get_account(conv),
		sapphire_id_from_conv(conv),
		purple_conversation_get_name(conv),
		"Chats");
}

/* Functions to upload icons to the proxy */

GHashTable *sent_icons;

static void
sapphire_send_icon(const gchar *name, const gchar *ext, gconstpointer data, size_t size, const gchar *hash)
{
	if (purple_strequal(g_hash_table_lookup(sent_icons, name), hash)) {
		/* Don't duplicate. */
		return;
	}

	/* If there is an active connection, send this icon. Otherwise, save
	 * it to be sent later */

	gchar *base64 = g_base64_encode(data, size);

	JsonObject *obj = json_object_new();
	json_object_set_string_member(obj, "op", "icon");
	json_object_set_string_member(obj, "name", name);
	json_object_set_string_member(obj, "ext", ext);
	json_object_set_string_member(obj, "base64", base64);

	gchar *str = json_object_to_string(obj);
	gchar *str_prefixed = g_strdup_printf(">%s", str);

	/* Assume that it needs to save the string. Callee will g_free it itself in the off-chance it doesn't need it anymore */
	sapphire_send_any_or_save(str_prefixed);

	/* Mark that the icon is sent so we don't try later */
	g_hash_table_insert(sent_icons, g_strdup(name), g_strdup(hash));

	g_free(str);
	g_free(base64);

	json_object_unref(obj);
}

void
sapphire_add_buddy_icon(const gchar *name, PurpleBuddyIcon *icon)
{
	size_t size;
	gconstpointer data = purple_buddy_icon_get_data(icon, &size);

	sapphire_send_icon(name, purple_buddy_icon_get_extension(icon), data, size, purple_buddy_icon_get_checksum(icon));
}

void
sapphire_add_stored_image(const gchar *name, PurpleStoredImage *icon)
{
	sapphire_send_icon(name, purple_imgstore_get_extension(icon), purple_imgstore_get_data(icon), purple_imgstore_get_size(icon), purple_imgstore_get_filename(icon));
}



/* Generic purple related helpers */

static PurpleStatus *
sapphire_status_for_buddy(PurpleBuddy *buddy)
{
	PurplePresence *presence = purple_buddy_get_presence(buddy);
	return purple_presence_get_active_status(presence);
}

static PurplePluginProtocolInfo *
sapphire_info_for_connection(PurpleConnection *connection)
{
	PurplePlugin *prpl = purple_connection_get_prpl(connection);
	return PURPLE_PLUGIN_PROTOCOL_INFO(prpl);
}

static PurpleConvIm *
sapphire_im_for_name(PurpleAccount *account, const char *name)
{
	PurpleConversation *conv = purple_find_conversation_with_account(PURPLE_CONV_TYPE_IM, name, account);

	if (conv == NULL) {
		/* If not found, create it */
		conv = purple_conversation_new(PURPLE_CONV_TYPE_IM, account, name);
	}

	return purple_conversation_get_im_data(conv);
}

/* Search for a PurpleConversation, either as a chat or an IM. Returns NULL if
 * not found */

static PurpleConversation *
sapphire_conversation_for_id(const gchar *id)
{
	PurpleConversation *as_chat = g_hash_table_lookup(blist_id_to_conversation, id);

	if (as_chat)
		return as_chat;

	PurpleBuddy *buddy = g_hash_table_lookup(id_to_buddy, id);

	if (buddy) {
		PurpleAccount *account = purple_buddy_get_account(buddy);
		const gchar *name = purple_buddy_get_name(buddy);

		return purple_find_conversation_with_account(PURPLE_CONV_TYPE_IM, name, account);
	}

	return NULL;
}

/* Helper to serialize and broadcast */

static void
sapphire_broadcast(JsonObject *msg)
{
	gchar *str = json_object_to_string(msg);
	sapphire_broadcast_raw_packet(str);
	g_free(str);
}

static void
sapphire_send(Connection *conn, JsonObject *msg)
{
	gchar *str = json_object_to_string(msg);
	sapphire_send_raw_packet(conn, str);
	g_free(str);
}

static PurpleTypingState
sapphire_decode_typing_state(int s_state);

static PurpleConversation *
sapphire_find_conversation(const gchar *chat);

static JsonArray *
sapphire_serialize_chat_users(SapphireChat *chat);

static PurpleBuddy *
sapphire_decode_buddy(JsonObject *data)
{
	const gchar *buddy_id = json_object_get_string_member(data, "buddy");

	if (!buddy_id)
		return NULL;

	/* Find the associated buddy */

	PurpleBuddy *buddy = g_hash_table_lookup(id_to_buddy, buddy_id);

	if (!buddy) {
		fprintf(stderr, "Bad buddy id %s\n", buddy_id);
		return NULL;
	}

	return buddy;
}

static PurpleAccount *
sapphire_decode_account(JsonObject *data)
{
	const gchar *account_id = json_object_get_string_member(data, "account");
	return g_hash_table_lookup(id_to_account, account_id);
}

void
sapphire_process_message(Connection *conn, JsonObject *data)
{
	const gchar *op = json_object_get_string_member(data, "op");

	if (purple_strequal(op, "message")) {
		/* Send an outgoing IM */

		const gchar *content = json_object_get_string_member(data, "content");

		/* Content is HTML, possibly OTR-encrypted so we can't do processing */
		gchar *marked = g_strdup(content);

		if (json_object_has_member(data, "buddy")) {
			PurpleBuddy *buddy = sapphire_decode_buddy(data);
			PurpleAccount *buddy_account = purple_buddy_get_account(buddy);
			const gchar *buddy_name = purple_buddy_get_name(buddy);

			purple_conv_im_send(sapphire_im_for_name(buddy_account, buddy_name), marked);
		} else if (json_object_has_member(data, "chat")) {
			const gchar *chat = json_object_get_string_member(data, "chat");
			PurpleConversation *conv = sapphire_find_conversation(chat);
			PurpleConvChat *conv_chat = purple_conversation_get_chat_data(conv);

			purple_conv_chat_send(conv_chat, marked);
		} else {
			fprintf(stderr, "No recipient specified in message\n");
			return;
		}

		g_free(marked);
	} else if (purple_strequal(op, "typing")) {
		/* Our buddy typing status changed */

		int s_state = json_object_get_int_member(data, "state");
		PurpleTypingState state = sapphire_decode_typing_state(s_state);

		PurpleBuddy *buddy = sapphire_decode_buddy(data);

		if (!buddy) {
			fprintf(stderr, "No buddy\n");
			return;
		}

		PurpleAccount *buddy_account = purple_buddy_get_account(buddy);
		const gchar *buddy_name = purple_buddy_get_name(buddy);

		PurpleConnection *connection = purple_account_get_connection(buddy_account);

		purple_serv_send_typing(connection, buddy_name, state);
	} else if (purple_strequal(op, "joinChat")) {
		/* Join a MUC */
		const gchar *id = json_object_get_string_member(data, "id");

		SapphireChat *chat = g_hash_table_lookup(id_to_chat, id);

		if (!chat) {
			printf("Chat not found %s\n", id);
			return;
		}

		gboolean is_subscribed = g_hash_table_contains(conn->subscribed_ids, id);

		if (g_hash_table_contains(id_to_joined, id)) {
			purple_roomlist_room_join(chat->roomlist, chat->room);
			g_hash_table_add(id_to_joined, g_strdup(id));
		} else if (!is_subscribed) {
			/* If we already joined but not in this connection, just send back details */

			const gchar *topic = purple_conv_chat_get_topic(PURPLE_CONV_CHAT(chat->conv));
			JsonArray *users = sapphire_serialize_chat_users(chat);

			JsonObject *data = json_object_new();
			json_object_set_string_member(data, "op", "joined");
			json_object_set_string_member(data, "chat", id);
			json_object_set_string_member(data, "topic", topic);
			json_object_set_array_member(data, "members", users);
			sapphire_send(conn, data);
			json_object_unref(data);
		}

		if (!is_subscribed) {
			/* We want to know about this room */
			g_hash_table_add(conn->subscribed_ids, g_strdup(id));
		}

	} else if (purple_strequal(op, "topic")) {
		const gchar *chat = json_object_get_string_member(data, "chat");
		const gchar *topic = json_object_get_string_member(data, "topic");

		PurpleConversation *conv = g_hash_table_lookup(blist_id_to_conversation, chat);
		PurpleConvChat *conv_chat = purple_conversation_get_chat_data(conv);
		int id = purple_conv_chat_get_id(conv_chat);

		PurpleAccount *account = purple_conversation_get_account(conv);
		PurpleConnection *connection = purple_account_get_connection(account);
		PurplePluginProtocolInfo *prpl_info = sapphire_info_for_connection(connection);

		if (prpl_info && prpl_info->set_chat_topic)
			prpl_info->set_chat_topic(connection, id, topic);
		else
			printf("Set chat topic unimplemented\n");
	} else if (purple_strequal(op, "markAsRead")) {
		const gchar *id = json_object_get_string_member(data, "id");

		/* Free the unacked list entries */

		GList *unacked_list = g_hash_table_lookup(id_to_unacked_list, id);

		for (GList *it = unacked_list; it != NULL; it = it->next) {
			JsonObject *msg = (JsonObject *) it->data;
			json_object_unref(msg);
		}

		/* Free the list itself */

		g_list_free(unacked_list);

		/* And remove it from the hash table */

		g_hash_table_remove(id_to_unacked_list, id);

		/* Fake a PURPLE_CONV_UPDATE_UNSEEN signal, so that the room gets
		 * marked as read.
		 */
		PurpleConversation *conv = sapphire_conversation_for_id(id);

		if (!conv) {
			fprintf(stderr, "Conversation not found in markAsRead %s\n", id);
			return;
		}

		purple_conversation_update(conv, PURPLE_CONV_UPDATE_UNSEEN);
	} else if (purple_strequal(op, "requestBuddy")) {
		/* Request to add a buddy */

		const gchar *id = json_object_get_string_member(data, "id");
		const gchar *alias = json_object_get_string_member(data, "alias");
		const gchar *invite = json_object_get_string_member(data, "invite");

		PurpleAccount *account = sapphire_decode_account(data);

		PurpleBuddy *buddy = purple_buddy_new(account, id, alias);
		purple_blist_add_buddy(buddy, NULL, NULL, NULL);
		purple_account_add_buddy_with_invite(account, buddy, invite);
	} else if (purple_strequal(op, "changeAvatar")) {
		/* Request to change our avatar */
		PurpleAccount *account = sapphire_decode_account(data);

		const gchar *base64 = json_object_get_string_member(data, "base64");
		size_t len;
		guchar *l_data = g_base64_decode(base64, &len);

		PurpleStoredImage *icon = purple_buddy_icons_set_account_icon(account, l_data, len);

		/* Update the cache */
		const gchar *raw_acct = json_object_get_string_member(data, "account");
		sapphire_add_stored_image(raw_acct, icon);

		/* Respond that we did it! */
		JsonObject *resp = json_object_new();
		json_object_set_string_member(resp, "op", "changeAvatar");
		json_object_set_string_member(resp, "id", raw_acct);
		sapphire_broadcast(resp);
		json_object_unref(resp);
	} else {
		fprintf(stderr, "Unknown op %s\n", op);
	}
}

/*** Conversation uiops ***/

static gchar *
sapphire_id_from_parts(PurpleAccount *account, const gchar *id);

static void
sapphire_signed_on(PurpleAccount *account, gpointer null)
{
	PurpleConnection *connection = purple_account_get_connection(account);

	/* Upsert the account ID */
	gchar *acct_id = sapphire_serialize_account_id(account);

	if (g_hash_table_contains(id_to_account, acct_id)) {
		/* Wait. We already did this account. Bail! TODO: Sync */
		g_free(acct_id);
		return;
	}

	g_hash_table_insert(id_to_account, acct_id, account);

	/* For type-1 prpls where the openness of a chat determines whether we
	 * receive events (e.g. IRC), open up all chats as early as possible */

	PurpleBlistNode *node;

	for (	node = purple_blist_get_root();
		node != NULL;
		node = purple_blist_node_next(node, TRUE)) {

		if (PURPLE_BLIST_NODE_IS_CHAT(node)) {
			PurpleChat *chat = PURPLE_CHAT(node);

			if (purple_chat_get_account(chat) != account) continue;

			GHashTable *components = purple_chat_get_components(chat);
			purple_serv_join_chat(connection, components);
		}
	}

	/* For type-2 prpls where we fetch from the room list (e.g. Discord),
	 * fetch now but do not open yet, since we don't want to spam the
	 * servers */

	if (purple_strequal(account->protocol_id, "prpl-eionrobb-discord")) {
		PurpleRoomlist *roomlist = purple_roomlist_get_list(connection);

		/* We're persisting the roomlist until later */
		purple_roomlist_ref(roomlist);

		gboolean in_progress = purple_roomlist_get_in_progress(roomlist);
		if (in_progress) {
			printf("In progress room list, aborting\n");
			return;
		}

		/* Check the field headings to figure out what to display (name) and index by (ID) */
		GList *field_headings = purple_roomlist_get_fields(roomlist);
		int index_id = -1, index_name = -1;

		int field_idx = 0;

		for (; field_headings != NULL; field_headings = field_headings->next, ++field_idx) {
			PurpleRoomlistField *field = (PurpleRoomlistField *) field_headings->data;

			const char *label = purple_roomlist_field_get_label(field);
			gboolean hidden = purple_roomlist_field_get_hidden(field);

			if (index_id == -1 && hidden) {
				index_id = field_idx;
			} else if (index_name == -1 && purple_strequal(label, "Name")) {
				index_name = field_idx;
			} else {
				/* Useless field */
			}
		}

		/* Now, scan the rooms */
		GList *rooms = roomlist->rooms; /* XXX: purple3 */

		for (; rooms != NULL; rooms = rooms->next) {
			PurpleRoomlistRoom *room = (PurpleRoomlistRoom *) rooms->data;

			PurpleRoomlistRoomType type = purple_roomlist_room_get_type(room);

			/* Skip over categories */

			if (type != PURPLE_ROOMLIST_ROOMTYPE_ROOM)
				continue;

			/* ...but do fetch our category name! */
			PurpleRoomlistRoom *parent = purple_roomlist_room_get_parent(room);
			const char *group_name = parent ? purple_roomlist_room_get_name(parent) : "Rooms";

			GList *fields = purple_roomlist_room_get_fields(room);

			const char *id = NULL;
			const char *display_name = NULL;

			for (int idx = 0; fields != NULL; fields = fields->next, ++idx) {
				gchar *value = (gchar *) fields->data;

				if (idx == index_id)
					id = value;

				if (idx == index_name)
					display_name = value;
			}

			/* XXX: Do magic from purple-discord to format ID */
			guint64 gid = g_ascii_strtoull(id, NULL, 10);
			int nid = ABS((gint) gid);
			gchar *snid = g_strdup_printf("%d", nid);
			gchar *sapphic_id = sapphire_id_from_parts(account, snid);
			g_free(snid);

			/* Save the chat */
			SapphireChat *schat = sapphire_new_chat(account, g_strdup(sapphic_id), display_name, group_name);

			schat->roomlist = roomlist;
			schat->room = room;

			printf("Saving Dithcord with %s\n", sapphic_id);
			chats = g_slist_prepend(chats, schat);

			/* No need to g_strdup(sapphic_id) since we already have the exclusive reference */
			g_hash_table_insert(id_to_chat, sapphic_id, schat);
		}
	}
}

static void
sapphire_account_enabled(PurpleAccount *account, gpointer null)
{
	printf("Account enabled: %s %s\n", account->username, account->protocol_id);
}

/* Serializes the actual content of a status */

static void
sapphire_serialize_status(JsonObject *data, PurpleStatus *status)
{
	JsonObject *obj = json_object_new();

	const gchar *id = purple_status_get_id(status);
	const gchar *name = purple_status_get_name(status);
	const gchar *message = purple_status_get_attr_string(status, "message");

	json_object_set_string_member(obj, "id", id);
	json_object_set_string_member(obj, "name", name);

	if (message != NULL)
		json_object_set_string_member(obj, "message", message);

	json_object_set_object_member(data, "status", obj);
}

/* Serializes a buddy "by reference", by hashing the buddy. Requires a
 * corresponding `buddy` op to be meaningful for the client. Requires
 * disambiguating by account, prpl, etc as well as just the name.
 * Simultaneously "upserts" the buddy into the global hash table for later
 * access.
 *
 * Result: serialized string, heap allocated. Must be g_free'd later.
 */

static gchar *
sapphire_serialize_user_id(PurpleAccount *account, const gchar *name)
{
	const gchar *prpl = purple_account_get_protocol_id(account);
	const gchar *account_id = purple_account_get_username(account);

	/* Smush together the features into a unique ID. TODO: Hash */

	gchar *smushed = g_strdup_printf("%s|%s|%s", prpl, account_id, name);
	return smushed;
}

static gchar *
sapphire_serialize_buddy_id(PurpleBuddy *buddy)
{
	/* Get distinguishing features */

	PurpleAccount *p_account = purple_buddy_get_account(buddy);

	const gchar *name = purple_normalize(p_account, purple_buddy_get_name(buddy));

	gchar *smushed = sapphire_serialize_user_id(p_account, name);

	/* Upsert. TODO: Will PurpleBuddy get garbage collected on us? */
	if (!g_hash_table_lookup(id_to_buddy, smushed)) {
		g_hash_table_replace(id_to_buddy, g_strdup(smushed), buddy);
	}

	return smushed;
}

/* Resolve from bare nickname who to actual ID */
static gchar *
sapphire_serialize_chat_user_id(PurpleConversation *conv, const gchar *who)
{
	PurpleAccount *account = purple_conversation_get_account(conv);
	PurpleConnection *connection = purple_account_get_connection(account);
	PurplePluginProtocolInfo *prpl_info = sapphire_info_for_connection(connection);

	if (prpl_info && prpl_info->get_cb_real_name) {
		/* Get the user's intra-protocol canonical name */

		PurpleConvChat *conv_chat = purple_conversation_get_chat_data(conv);
		int id = purple_conv_chat_get_id(conv_chat);
		gchar *real_name = prpl_info->get_cb_real_name(connection, id, who);
		gchar *normalized = g_strdup(purple_normalize(account, real_name));

		/* Check if it's, uh, us */
		const char *username = purple_normalize(account, purple_account_get_username(account));
		const char *display_name = purple_connection_get_display_name(connection);

		if (purple_strequal(username, normalized) || purple_strequal(display_name, normalized)) {
			g_free(normalized);
			return sapphire_serialize_account_id(account);
		}

		printf("From %s to %s to %s\n", who, real_name, normalized);

		/* Serialize it formally for protocol independence */
		gchar *out = sapphire_serialize_user_id(account, normalized);
		g_free(normalized);
		g_free(real_name);
		return out;
	} else {
		printf("Bailing on %s\n", who);
		return g_strdup(who);
	}
}

static void
sapphire_serialize_buddy(JsonObject *data, PurpleBuddy *buddy)
{
	gchar *id = sapphire_serialize_buddy_id(buddy);
	json_object_set_string_member(data, "buddy", id);
	g_free(id);
}

static void
sapphire_serialize_chat_buddy(JsonObject *data, PurpleConversation *conv, const char *who, PurpleConvChatBuddyFlags flags)
{
	gchar *user_id = sapphire_serialize_chat_user_id(conv, who);
	json_object_set_string_member(data, "id", user_id);
	json_object_set_string_member(data, "alias", who);
	json_object_set_int_member(data, "flags", flags);
	g_free(user_id);
}

/* Add missed messages to buddy/chat object if applicable */

static void
sapphire_serialize_unacked_messages(JsonObject *obj, const gchar *id)
{
	GList *lst = g_hash_table_lookup(id_to_unacked_list, id);

	if (!lst)
		return;

	/* Pop missed messages in reverse order */

	GList *it;
	JsonArray *unacked = json_array_new();

	for (it = lst; it != NULL; it = it->next) {
		JsonObject *msg = (JsonObject *) it->data;
		json_array_add_object_element(unacked, msg);
	}

	json_object_set_array_member(obj, "unacked", unacked);
}

static JsonArray *
sapphire_serialize_chat_users(SapphireChat *chat)
{
	JsonArray *jusers = json_array_new();

	if (chat->conv) {
		PurpleConvChat *conv_chat = purple_conversation_get_chat_data(chat->conv);

		for (GList *l = purple_conv_chat_get_users(conv_chat); l != NULL; l = l->next) {
			JsonObject *juser = json_object_new();

			PurpleConvChatBuddy *cb = (PurpleConvChatBuddy *) l->data;
			const gchar *who = purple_conv_chat_cb_get_name(cb);
			PurpleConvChatBuddyFlags flags = purple_conv_chat_user_get_flags(conv_chat, who);
			printf("For %s %s\n", cb->name, cb->alias);
			sapphire_serialize_chat_buddy(juser, chat->conv, who, flags);

			json_array_add_object_element(jusers, juser);
		}
	}

	return jusers;
}

/* Serializes the unopened chat pieces, not the conversation bits which have a
 * rather more complex path */

static JsonObject *
sapphire_serialize_chat(SapphireChat *chat)
{
	JsonObject *obj = json_object_new();

	json_object_set_string_member(obj, "id", chat->id);
	json_object_set_string_member(obj, "name", chat->name);
	json_object_set_string_member(obj, "group", chat->group);
	json_object_set_string_member(obj, "account", chat->account_id);

	sapphire_serialize_unacked_messages(obj, chat->id);

	return obj;
}

/* Creates pass-by-reference ID for account.
 *
 * Return: ID as a string (must be freed by caller)
 */

static gchar *
sapphire_serialize_account_id(PurpleAccount *account)
{
	/* Get features */
	const gchar *prpl = purple_account_get_protocol_id(account);
	const gchar *username = purple_account_get_username(account);

	/* Smush prpl with username to form an ID */
	return g_strdup_printf("%s|%s", prpl, username);

}

/* Serialize actual chat ID */

static gchar *
sapphire_id_from_conv(PurpleConversation *chat)
{
	PurpleAccount *account = purple_conversation_get_account(chat);
	gchar *acct = sapphire_serialize_account_id(account);

	PurpleConvChat *conv_chat = purple_conversation_get_chat_data(chat);
	int id = purple_conv_chat_get_id(conv_chat);

	gchar *full_id = g_strdup_printf("%s|%d", acct, id);
	g_free(acct);

	return full_id;
}

/* Find a chat by ID */

static SapphireChat *
sapphire_find_chat(const gchar *id, gboolean use_id)
{
	for (GSList *it = chats; it != NULL; it = it->next) {
		SapphireChat *candidate = (SapphireChat *) it->data;

		gboolean match = FALSE;

		if (use_id) {
			match = purple_strequal(candidate->id, id);
		} else if (candidate->conv) {
			/* Ignore the provided ID and compute it ourselves */
			gchar *chat = sapphire_id_from_conv(candidate->conv);
			match = purple_strequal(id, chat);
			g_free(chat);
		} else {
			printf("ERROR: ID ignored but NULL conv\n");
			return NULL;
		}

		if (match)
			return candidate;
	}

	return NULL;
}

/* Find conversation by ID, the fast way or the slow way.. */

static PurpleConversation *
sapphire_find_conversation(const gchar *chat)
{
	PurpleConversation *conv = g_hash_table_lookup(blist_id_to_conversation, chat);

	if (conv)
		return conv;

	/* Not in the hash table -- so iterate */
	SapphireChat *schat = sapphire_find_chat(chat, FALSE);

	return schat ? schat->conv : NULL;
}

static gchar *
sapphire_id_from_parts(PurpleAccount *account, const gchar *id)
{
	gchar *acct = sapphire_serialize_account_id(account);

	return g_strdup_printf("%s|%s", acct, id);
}

/* By contrast, this routine serializes a buddy by value, including the ID
 * generated by the previous function as well as the actual metadata */

static JsonObject *
sapphire_serialize_buddy_object(PurpleBuddy *buddy)
{
	JsonObject *json = json_object_new();

	PurpleGroup *group = purple_buddy_get_group(buddy);

	const gchar *name = purple_buddy_get_name(buddy);
	const gchar *alias = purple_buddy_get_contact_alias(buddy);
	const gchar *group_name = purple_group_get_name(group);

	gchar *id = sapphire_serialize_buddy_id(buddy);

	/* We might have an icon. If so, get it ready for later access, but do
	 * not send it here. Merely record if there is an icon or not */

	PurpleAccount *account = purple_buddy_get_account(buddy);
	PurpleBuddyIcon *icon = purple_buddy_icons_find(account, name);

	if (icon != NULL) {
		purple_buddy_icon_ref(icon);

		sapphire_add_buddy_icon(id, icon);
	}

	json_object_set_boolean_member(json, "hasIcon", icon != NULL);

	json_object_set_string_member(json, "id", id);
	json_object_set_string_member(json, "name", name);
	json_object_set_string_member(json, "alias", alias);
	json_object_set_string_member(json, "group", group_name);

	gchar *accountID = sapphire_serialize_account_id(account);

	if (accountID) {
		json_object_set_string_member(json, "account", accountID);
		g_free(accountID);
	}

	sapphire_serialize_status(json, sapphire_status_for_buddy(buddy));

	/* Include the ID of the buddy itself */
	sapphire_serialize_buddy(json, buddy);

	g_free(id);

	return json;
}

/* Serialize the account itself for personal information */

static JsonObject *
sapphire_serialize_account(PurpleAccount *account)
{
	JsonObject *json = json_object_new();

	const gchar *prpl = purple_account_get_protocol_id(account);
	const gchar *prpl_name = purple_account_get_protocol_name(account);
	const gchar *username = purple_account_get_username(account);
	const gchar *alias = purple_account_get_alias(account);

	json_object_set_string_member(json, "prpl", prpl);
	json_object_set_string_member(json, "prplName", prpl_name);
	json_object_set_string_member(json, "name", username);
	json_object_set_string_member(json, "alias", alias);

	gchar *id = sapphire_serialize_account_id(account);
	json_object_set_string_member(json, "id", id);

	/* Add our own icon, if applicable, to the store */
	PurpleStoredImage *icon =
	       	purple_buddy_icons_find_account_icon(account);

	if (icon)
		sapphire_add_stored_image(id, icon);

	json_object_set_boolean_member(json, "hasIcon", icon != NULL);

	g_free(id);

	return json;
}

/* Sends the entire world to a new connection. For this, we need to send:
 *
 * - information about our accounts
 * - the buddy list
 * - rooms we're in 
 * - missed messages
 *
 * Essentially, everything needed for the initial client render.
 *
 * We do _not_ need to send anything that's not immediately accessible; for
 * instance, we can avoid sending the users in present rooms that are not on
 * our buddy list, deferring to when we explicitly open the room
 *
 */

void
sapphire_send_world(Connection *conn)
{
	/* Initialize connected state */
	conn->subscribed_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);

	JsonObject *data = json_object_new();

	json_object_set_string_member(data, "op", "world");

	/* Iterate the buddy list of connected accounts to include buddies */

	JsonArray *jbuddies = json_array_new();
	JsonArray *jaccounts = json_array_new();
	JsonArray *jchats = json_array_new();

	GSList *acct;

	for (acct = purple_accounts; acct != NULL; acct = acct->next) {
		PurpleAccount *account = (PurpleAccount *) acct->data;

		/* Add buddies from account */

		GSList *blist = purple_find_buddies(account, NULL);

		for (GSList *it = blist; it != NULL; it = it->next) {
			PurpleBuddy *buddy = (PurpleBuddy *) it->data;
			JsonObject *bud = sapphire_serialize_buddy_object(buddy);

			const gchar *bid = json_object_get_string_member(bud, "buddy");
			sapphire_serialize_unacked_messages(bud, bid);

			json_array_add_object_element(jbuddies, bud);
		}

		/* Add metadata for the account itself */

		JsonObject *j_account = sapphire_serialize_account(account);
		json_array_add_object_element(jaccounts, j_account);

		g_slist_free(blist);
	}

	/* Send chats */

	for (GSList *it = chats; it != NULL; it = it->next) {
		SapphireChat *schat = (SapphireChat *) it->data;
		json_array_add_object_element(jchats, sapphire_serialize_chat(schat));
	}

	/* TODO: What if one is.. both? */

	json_object_set_array_member(data, "buddies", jbuddies);
	json_object_set_array_member(data, "chats", jchats);
	json_object_set_array_member(data, "accounts", jaccounts);

	sapphire_send(conn, data);
	json_object_unref(data);
}

static void
sapphire_buddy_status_changed(PurpleBuddy *buddy, gpointer null)
{
	JsonObject *data = json_object_new();

	json_object_set_string_member(data, "op", "buddyStatus");

	sapphire_serialize_status(data, sapphire_status_for_buddy(buddy));
	sapphire_serialize_buddy(data, buddy);

	sapphire_broadcast(data);
	json_object_unref(data);
}

static void
sapphire_serialize_typing_state(JsonObject *data, PurpleTypingState state)
{
	/* While we could pass is, that risks future libpurple updates causing
	 * breakage */

	int s_state =
		(state == PURPLE_NOT_TYPING) 	? 0 :
		(state == PURPLE_TYPING) 	? 1 :
		(state == PURPLE_TYPED) 	? 2 :
						 -1;

	json_object_set_int_member(data, "state", s_state);
}

static PurpleTypingState
sapphire_decode_typing_state(int s_state)
{
	return (s_state == 0) ? PURPLE_NOT_TYPING :
		(s_state == 1) ? PURPLE_TYPING :
		(s_state == 2) ? PURPLE_TYPED :
		PURPLE_NOT_TYPING;
}

static void
sapphire_buddy_typing_changed(PurpleAccount *account, const char *name, gpointer null)
{
	JsonObject *data = json_object_new();

	json_object_set_string_member(data, "op", "typing");

	PurpleBuddy *buddy = purple_find_buddy(account, name);
	sapphire_serialize_buddy(data, buddy);

	PurpleConvIm *im = sapphire_im_for_name(account, name);
	PurpleTypingState state = purple_conv_im_get_typing_state(im);
	sapphire_serialize_typing_state(data, state);

	sapphire_broadcast(data);
	json_object_unref(data);
}

static void
sapphire_received_message(PurpleAccount *account, const char *who, const char *message, PurpleConversation *conv,
			PurpleMessageFlags flags, gpointer null)
{
	/* Find the buddy since the arguments as-is are difficult to work with */

	PurpleBuddy *buddy = NULL;

	/* Whether channel_id needs a g_free */
	gboolean should_free_channel_id = TRUE;
	gchar *channel_id;

	JsonObject *data = json_object_new();

	json_object_set_string_member(data, "op", "message");

	/* Serialization depends on the type of "buffer" in use; we don't
	 * smooth out the incongruence between IMs and chats until we're in
	 * backend.js on the client */

	PurpleConversationType type = purple_conversation_get_type(conv);

	if (type == PURPLE_CONV_TYPE_IM) {
		/* Serialize the buddy we're talking to */
		buddy = purple_find_buddy(account, who);
		sapphire_serialize_buddy(data, buddy);

		channel_id = sapphire_serialize_buddy_id(buddy);
	} else if (type == PURPLE_CONV_TYPE_CHAT) {
		/* Serialize the chat itself */
		channel_id = sapphire_id_from_conv(conv);
		json_object_set_string_member(data, "chat", channel_id);

		if (flags & PURPLE_MESSAGE_SYSTEM) {
			json_object_set_string_member(data, "who", "system");
		} else {
			/* And just the ID of who sent it. */
			gchar *user_id = sapphire_serialize_chat_user_id(conv, who);
			json_object_set_string_member(data, "who", user_id);
			g_free(user_id);

			/* ...in case the user is offline and not a buddy, also supply an alias */
			json_object_set_string_member(data, "alias", who);
		}

	} else {
		printf("Wat? nonbuddy, non chat?\n");
	}

	//json_object_set_int_member(data, "time", mtime);
	json_object_set_int_member(data, "flags", flags);

	/* Since we might be OTR-protected, the backend can't do anything with the plaintext */
	json_object_set_string_member(data, "content", message);

	/* So, if there are connected clients, we broadcast to them. Otherwise, we need to
	 * store the message, so we can replay messages later for when we
	 * connect. It's okay if the lookup fails and we null, g_list functions
	 * don't mind. Additionally, if this is the first message like this,
	 * we'll need to send a push notification. */

	if (sapphire_any_connected_clients()) {
		/* Broadcast */
		gchar *str = json_object_to_string(data);
		sapphire_broadcast_raw_packet(str);
	} else {
		/* Save the message */

		if (type == PURPLE_CONV_TYPE_IM) {
			GList *unacked_list = g_hash_table_lookup(id_to_unacked_list, channel_id);
			unacked_list = g_list_prepend(unacked_list, json_object_ref(data));
			g_hash_table_replace(id_to_unacked_list, channel_id, unacked_list);
			should_free_channel_id = FALSE;
		} else {
			/* TODO: Chats. At the moment, these can accumulate
			 * huge amounts of memory, so disabling for now, mk? */
		}
	}

	/* Send a notification for IMs. The push notification module will
	 * determine if it's necessary */

	if (type == PURPLE_CONV_TYPE_IM) {
		const char *alias = purple_buddy_get_alias(buddy);
		gchar *notification = g_strdup_printf("Psst, %s messaged you via Sapphire\n", alias);
		sapphire_push_notification(notification);
		g_free(notification);
	}

	if (should_free_channel_id)
		g_free(channel_id);

	json_object_unref(data);
}

static void
sapphire_topic_changed(PurpleConversation *conv, const char *who, const char *topic, gpointer null)
{
	JsonObject *data = json_object_new();

	gchar *chat_id = sapphire_id_from_conv(conv);

	json_object_set_string_member(data, "op", "topic");
	json_object_set_string_member(data, "who", who);
	json_object_set_string_member(data, "topic", topic);
	json_object_set_string_member(data, "chat", chat_id);
	sapphire_broadcast(data);
	json_object_unref(data);

	g_free(chat_id);
}

/* A buddy joined in a room we're subscribed to -- but that doesn't mean the
 * client needs to know. Only send the joined event to clients that have opened
 * the corresponding conversation */

extern GList *authenticated_connections;

static void
sapphire_buddy_joined(PurpleConversation *conv, const char *who, PurpleConvChatBuddyFlags flags, gboolean new_arrival, gpointer null)
{
	JsonObject *data = json_object_new();
	gchar *chat_id = sapphire_id_from_conv(conv);
	json_object_set_string_member(data, "op", "joined");
	json_object_set_string_member(data, "chat", chat_id);

	/* Send a single element worth of users :( */

	JsonArray *lst = json_array_new();
	JsonObject *buddy = json_object_new();
	printf("Got %s\n", who);
	sapphire_serialize_chat_buddy(buddy, conv, who, flags);
	json_array_add_object_element(lst, buddy);
	json_object_set_array_member(data, "members", lst);

	/* TODO: Maybe don't serialize so many times */
	/* TODO: Don't serialize at all if nobody's subscribed */
	for (GList *it = authenticated_connections; it != NULL; it = it->next) {
		Connection *conn = (Connection *) it->data;

		if (g_hash_table_contains(conn->subscribed_ids, chat_id))
			sapphire_send(conn, data);
	}

	g_free(chat_id);
	json_object_unref(data);
}


static void
sapphire_joined_chat(PurpleConversation *conv, gpointer null)
{
	/* Try to use the existing chat */

	gchar *id = sapphire_id_from_conv(conv);
	SapphireChat *schat = sapphire_find_chat(id, TRUE);

	if (!schat) {
		/* Surprise! Create a new chat */

		schat = sapphire_chat_from_conv(conv);
		schat->conv = conv;
		printf("Joining chat %s\n", schat->id);
		chats = g_slist_append(chats, schat);
	} else {
		/* Associate with the conv */
		schat->conv = conv;
	}

	g_hash_table_insert(blist_id_to_conversation, id, conv);

	/* It's joined! */

	if (!g_hash_table_contains(id_to_joined, id)) {
		g_hash_table_add(id_to_joined, g_strdup(id));
		g_hash_table_insert(id_to_chat, g_strdup(id), schat);
	}

}

/* Certain prpls, particularly those for third-party protocols, should be
 * disabled when not in active use. This function, called from the socket
 * handling when a client connects or disconnects, checks if there are active
 * connections. If there are, relevant prpls are enabled; if not, they are
 * disabled. */

static gboolean
sapphire_prpl_defer_connects(const gchar *protocol_id)
{
	return purple_strequal(protocol_id, "prpl-eionrobb-discord");
}

void
sapphire_enable_accounts_by_connections(void)
{
	gboolean should_enable = sapphire_any_connected_clients();

	for (GSList *it = purple_accounts; it != NULL; it = it->next) {
		PurpleAccount *account = (PurpleAccount *) it->data;

		const gchar *protocol_id = purple_account_get_protocol_id(account);

		/* Check if the protocol has this quirk */

		if (!sapphire_prpl_defer_connects(protocol_id))
			continue;

		/* It does -- so check which direction we need to go */
		gboolean enab = purple_account_get_enabled(account, UI_ID);

		if (should_enable != enab) {
			purple_account_set_enabled(account, UI_ID, should_enable);
		}
	}
}

#ifdef _WIN32
#include <windows.h>
extern BOOL SetDllDirectoryA(LPCSTR lpPathName);
typedef void (WINAPI* LPFNSETDLLDIRECTORY)(LPCSTR);
static LPFNSETDLLDIRECTORY MySetDllDirectory = NULL;
#endif

static void
init_libpurple(void)
{
#ifdef _WIN32
	purple_util_set_user_dir("./.purple");

	HMODULE hmod;
	if ((hmod = GetModuleHandleW(L"kernel32.dll"))) {
		MySetDllDirectory = (LPFNSETDLLDIRECTORY) GetProcAddress(
			hmod, "SetDllDirectoryA");
		if (!MySetDllDirectory)
			printf("SetDllDirectory not supported\n");
	} else
		printf("Error getting kernel32.dll module handle\n");

	/* For Windows XP SP1+ / Server 2003 we use SetDllDirectory to avoid dll hell */
	if (MySetDllDirectory) {
		printf("Using SetDllDirectory\n");
		MySetDllDirectory("C:/Program Files (x86)/Pidgin/");
	}
#endif

	gchar *search_path = g_build_filename(purple_user_dir(), "plugins", NULL);
	purple_plugins_add_search_path(search_path);
	g_free(search_path);

#ifdef _WIN32
	purple_plugins_add_search_path("C:/Program Files (x86)/Pidgin/plugins/");
	purple_plugins_add_search_path("C:/Program\\ Files\\ \\(x86\\)/Pidgin/");
	purple_plugins_add_search_path("C:/Program\\ Files\\ \\(x86\\)/Pidgin/plugins/");
#endif

	purple_debug_set_enabled(FALSE);

	sapphire_set_eventloop();

	if (!purple_core_init(UI_ID)) {
		fprintf(stderr,
				"libpurple initialization failed. Dumping core.\n"
				"Please report this!\n");
		abort();
	}

	purple_set_blist(purple_blist_new());
	purple_blist_load();
	purple_prefs_load();
	purple_plugins_load_saved(PLUGIN_SAVE_PREF);
	purple_pounces_load();
}
static void
sapphire_connect_signals(void)
{
	static int handle;
	purple_signal_connect(purple_accounts_get_handle(), "account-signed-on", &handle,
				PURPLE_CALLBACK(sapphire_signed_on), NULL);
	purple_signal_connect(purple_accounts_get_handle(), "account-enabled", &handle,
				PURPLE_CALLBACK(sapphire_account_enabled), NULL);

	purple_signal_connect(purple_blist_get_handle(), "buddy-signed-on", &handle,
				PURPLE_CALLBACK(sapphire_buddy_status_changed), NULL);
	purple_signal_connect(purple_blist_get_handle(), "buddy-signed-off", &handle,
				PURPLE_CALLBACK(sapphire_buddy_status_changed), NULL);
	purple_signal_connect(purple_blist_get_handle(), "buddy-status-changed", &handle,
				PURPLE_CALLBACK(sapphire_buddy_status_changed), NULL);

	purple_signal_connect(purple_conversations_get_handle(), "wrote-im-msg", &handle,
				PURPLE_CALLBACK(sapphire_received_message), NULL);
	purple_signal_connect(purple_conversations_get_handle(), "wrote-chat-msg", &handle,
				PURPLE_CALLBACK(sapphire_received_message), NULL);

	purple_signal_connect(purple_conversations_get_handle(), "buddy-typing", &handle,
				PURPLE_CALLBACK(sapphire_buddy_typing_changed), NULL);
	purple_signal_connect(purple_conversations_get_handle(), "buddy-typed", &handle,
				PURPLE_CALLBACK(sapphire_buddy_typing_changed), NULL);
	purple_signal_connect(purple_conversations_get_handle(), "buddy-typing-stopped", &handle,
				PURPLE_CALLBACK(sapphire_buddy_typing_changed), NULL);

	purple_signal_connect(purple_conversations_get_handle(), "chat-joined", &handle,
				PURPLE_CALLBACK(sapphire_joined_chat), NULL);


	purple_signal_connect(purple_conversations_get_handle(), "chat-buddy-joined", &handle,
				PURPLE_CALLBACK(sapphire_buddy_joined), NULL);
	purple_signal_connect(purple_conversations_get_handle(), "chat-topic-changed", &handle,
				PURPLE_CALLBACK(sapphire_topic_changed), NULL);
}

int main(int argc, char *argv[])
{
	GMainLoop *loop;
	PurpleSavedStatus *status;

#ifndef _WIN32
	/* libpurple's built-in DNS resolution forks processes to perform
	 * blocking lookups without blocking the main process.  It does not
	 * handle SIGCHLD itself, so if the UI does not you quickly get an army
	 * of zombie subprocesses marching around.
	 */
	signal(SIGCHLD, SIG_IGN);
#endif

#ifdef _WIN32
	g_thread_init(NULL);
#endif

	g_set_prgname("Sapphire");
	g_set_application_name("Sapphire");

	loop = g_main_loop_new(NULL, FALSE);
	g_main_loop_ref(loop);

	gboolean jailed = (argc >= 2) && (purple_strequal(argv[1], "--jailed"));

	if (jailed) {
		/* If we're running in firejail, we can't use a .purple, since
		 * the hidden nature will cause permission errors. Instead, use
		 * an opaque name */

		purple_util_set_user_dir("./purple");
	}

	init_libpurple();

	purple_prefs_add_none("/purple/sapphire");

	if (!purple_prefs_get_string(SAPPHIRE_PUSH_EMAIL_PREF)) {
		printf("Push notification email (blank to disable): ");

		char email[128];
		fgets(email, sizeof(email), stdin);

		purple_prefs_add_string(SAPPHIRE_PUSH_EMAIL_PREF, email);
	}

	/* Initialize global hash tables */
	id_to_buddy = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
	id_to_unacked_list = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
	id_to_chat = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
	id_to_account = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
	id_to_joined = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
	blist_id_to_conversation = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
	sent_icons = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);

	sapphire_connect_signals();

	sapphire_init_websocket();

	g_main_context_iteration(g_main_loop_get_context(loop), FALSE);

	GList *l;

	/* Fetch account and enable it */
	for (l = purple_accounts_get_all(); l != NULL; l = l->next) {
		PurpleAccount *candidate = (PurpleAccount *)l->data;

		const gchar *protocol_id = purple_account_get_protocol_id(candidate);
		if (purple_strequal(protocol_id, "prpl-jabber") || purple_strequal(protocol_id, "prpl-eionrobb-discord")) {
			purple_accounts = g_slist_append(purple_accounts, candidate);

			purple_account_set_enabled(candidate, UI_ID, !sapphire_prpl_defer_connects(protocol_id));
		} else {
			purple_account_set_enabled(candidate, UI_ID, FALSE);
		}
	}

	if (!purple_accounts) {
		fprintf(stderr, "No accounts found\n");
		return 1;
	}

	/* Now, to connect the account(s), create a status and activate it. */
	status = purple_savedstatus_new(NULL, PURPLE_STATUS_AVAILABLE);
	purple_savedstatus_activate(status);

	g_main_loop_run(loop);

	return 0;
}
